<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>AS2 MissionUpdate 表单（Zenoh REST）</title>
<style>
  :root { --bg:#0b1220; --card:#121a2b; --ink:#e8eefc; --muted:#9fb3d9; --accent:#65b; --ok:#22aa6a; --warn:#d7a32a; --bad:#e05555; }
  html,body{margin:0;height:100%;font:14px/1.4 system-ui,Segoe UI,Roboto,Arial;color:var(--ink);background:linear-gradient(160deg,#0b1220,#0a0f1a);}
  .wrap{max-width:1100px;margin:24px auto;padding:0 16px 48px;}
  h1{font-size:22px;margin:8px 0 16px;letter-spacing:.2px}
  h3{margin:6px 0 10px}
  .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
  .card{background:var(--card);border:1px solid #1f2a46;padding:14px;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.25)}
  .row{display:flex;gap:10px;align-items:center;margin:8px 0}
  .row > label{min-width:140px;color:var(--muted)}
  input[type="text"], input[type="number"], textarea, select{
    width:100%;background:#0d1526;border:1px solid #27365a;color:var(--ink);
    border-radius:12px;padding:10px 12px;outline:none;transition:.15s;
  }
  textarea{min-height:120px;resize:vertical}
  input:focus, textarea:focus, select:focus{border-color:#3a57a8;box-shadow:0 0 0 3px rgba(58,87,168,.25)}
  .inline{display:flex;gap:10px}
  .hint{color:var(--muted);font-size:12px;margin-top:6px}
  .btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
  button{
    border:0;background:#2b3b66;color:#eef;border-radius:12px;padding:10px 14px;font-weight:600;cursor:pointer;
    transition:transform .04s ease-out,filter .15s;
  }
  button:hover{filter:brightness(1.1)}
  button:active{transform:translateY(1px)}
  .ghost{background:#1a233b}
  .ok{background:var(--ok)}
  .warn{background:var(--warn)}
  .bad{background:var(--bad)}
  code,kbd,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
  .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
  .pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:#16213a;border:1px solid #26345a;color:#cfe}
  .preview{white-space:pre-wrap;word-break:break-word;background:#0b1220;border:1px dashed #2a3c6a;border-radius:12px;padding:10px}
  .sep{height:1px;background:#1e2a44;margin:10px 0}
  .small{font-size:12px;color:#a9bbdf}
  .title{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
  .kv{display:grid;grid-template-columns:120px 1fr;gap:6px;font-size:13px}
  .muted{color:var(--muted)}
  .badge{display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid #2a3c6a;background:#101831;font-size:12px}
  .two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
</style>
</head>
<body>
<div class="wrap">
  <div class="title">
    <h1>AS2 MissionUpdate 表单（Zenoh REST）</h1>
    <span class="pill">一键：点列 → Mission JSON → /key=rt/mission_id_xxx/…</span>
  </div>

  <div class="grid">
    <!-- 左列：参数输入 -->
    <div class="card">
      <h3>基础设置</h3>
      <div class="row">
        <label>REST 基址</label>
        <input id="restBase" type="text" value="http://47.109.192.179:8100" />
      </div>
      <div class="row">
        <label>Zenoh Key（前缀）</label>
        <input id="zenohKey" type="text" value="rt" />
      </div>
      <div class="row">
        <label>Namespace</label>
        <input id="ns" type="text" value="uav_1" />
      </div>
      <div class="inline">
        <div class="row" style="flex:1">
          <label>Mission ID</label>
          <input id="mid" type="number" value="5001" />
        </div>
        <div class="row" style="flex:1">
          <label>巡航速度 m/s</label>
          <input id="speed" type="number" step="0.1" value="3.0" />
        </div>
      </div>
      <div class="inline">
        <div class="row" style="flex:1">
          <label>起飞高度 m</label>
          <input id="takeoffH" type="number" step="0.1" value="10.0" />
        </div>
        <div class="row" style="flex:1">
          <label>收尾降高度 m</label>
          <input id="endZ" type="number" step="0.1" value="0.8" />
        </div>
      </div>
      <div class="inline">
        <div class="row" style="flex:1">
          <label>Yaw 模式</label>
          <select id="yawMode">
            <option value="0">0: YAW_ANGLE</option>
            <option value="1">1: YAW_SPEED</option>
          </select>
        </div>
        <div class="row" style="flex:1">
          <label>Yaw 角(°)</label>
          <input id="yawAngle" type="number" step="0.1" value="0.0" />
        </div>
      </div>

      <div class="sep"></div>

      <h3>任务类型</h3>
      <div class="row">
        <label>使用 GPS</label>
        <select id="mode">
          <option value="gps">GPS 路径 (follow_path_gps)</option>
          <option value="map">map/odom 路径 (follow_path)</option>
          <option value="gps_fallback">GPS 优先，失败回退 map</option>
        </select>
      </div>

      <div class="row">
        <label>GPS 点列</label>
        <textarea id="gpsPath" placeholder="每行: lat, lon, alt
40.440500, -3.689820, 10
40.440700, -3.689820, 10
…"></textarea>
      </div>
      <div class="hint">格式：<code>纬度, 经度, 高度</code>，以逗号或空格分隔，每行一个点。</div>

      <div class="row" style="margin-top:14px">
        <label>map/odom 点列</label>
        <textarea id="mapPath" placeholder="每行: x, y, z
0, 0, 10
50, 0, 10
50, 50, 10
0, 50, 10
0, 0, 10"></textarea>
      </div>
      <div class="hint">格式：<code>x, y, z</code>（米），以逗号或空格分隔，每行一个点。</div>

      <div class="btns">
        <button class="ghost" onclick="prefillDemo()">填入示例</button>
        <button onclick="buildJSON()">生成 MissionUpdate JSON</button>
      </div>
    </div>

    <!-- 右列：预览与发送 -->
    <div class="card">
      <h3>载荷预览与下发</h3>
      <div class="row">
        <label>LOAD 载荷</label>
        <textarea id="payloadLOAD" class="mono" readonly></textarea>
      </div>
      <div class="row">
        <label>START 载荷</label>
        <textarea id="payloadSTART" class="mono" readonly></textarea>
      </div>

      <div class="hint">目标 URL（自动生成）：<br>
        <span id="targetUrl" class="small mono"></span>
      </div>

      <div class="btns">
        <button class="ok" onclick="sendLOAD()">发送 LOAD</button>
        <button class="ok" onclick="sendSTART()">发送 START</button>
        <button class="warn" onclick="sendBoth()">LOAD + START</button>
      </div>

      <div class="sep"></div>

      <div class="row">
        <label>响应</label>
        <pre id="resp" class="preview"></pre>
      </div>
    </div>
  </div>

  <!-- 新增：实时查看（telemetry/pose & mission_status） -->
  <div class="card" style="margin-top:16px">
    <h3>实时查看（Zenoh → 前端）</h3>
    <div class="two-col">
      <div>
        <div class="row">
          <label>Telemetry Pose Key</label>
          <input id="teleKey" type="text" value="web/uav_1/telemetry/pose" />
        </div>
        <div class="row">
          <label>Mission Status Key</label>
          <input id="statusKey" type="text" value="web/uav_1/mission_status" />
        </div>
        <div class="inline">
          <div class="row" style="flex:1">
            <label>轮询周期 ms</label>
            <input id="pollMs" type="number" value="500" />
          </div>
          <div class="row" style="flex:1">
            <label></label>
            <div class="btns">
              <button class="ok" onclick="startListen()">开始监听</button>
              <button class="bad" onclick="stopListen()">停止</button>
            </div>
          </div>
        </div>
        <div class="hint">使用 HTTP GET 轮询 Zenoh REST（适配常见部署）。如你的网关支持 SSE/WebSocket，可后续升级。</div>
      </div>
      <div>
        <div style="margin-bottom:8px"><span class="badge">最新 Telemetry</span></div>
        <div class="kv small">
          <div class="muted">frame_id</div><div id="t_frame">-</div>
          <div class="muted">stamp</div><div id="t_stamp">-</div>
          <div class="muted">x</div><div id="t_x">-</div>
          <div class="muted">y</div><div id="t_y">-</div>
          <div class="muted">z / altitude</div><div id="t_z">-</div>
        </div>
      </div>
    </div>

    <div class="sep"></div>

    <div style="margin-bottom:8px"><span class="badge">Mission Status 最新消息</span></div>
    <pre id="statusView" class="preview" style="min-height:120px"></pre>
  </div>

  <div class="card" style="margin-top:16px">
    <h3>使用提示</h3>
    <ul>
      <li>Zenoh REST PUT：<code>{REST_BASE}/{key}</code>；本页已按此风格发送。</li>
      <li>实时查看使用 GET 轮询：<code>{REST_BASE}/{key}</code>，返回最近一次写入（多数网关默认支持）。</li>
      <li>如果你的 REST 网关仅支持 <code>/key=...</code> 形式，请在本页把 key 文本框写成 <code>key=rt/...</code> 即可。</li>
    </ul>
  </div>
</div>

<script>
const $ = (id) => document.getElementById(id);

function parseList(str, expectLen=3) {
  const out = [];
  (str || "").split(/\n+/).map(s => s.trim()).filter(Boolean).forEach(line => {
    const parts = line.split(/[, \t]+/).map(x => x.trim()).filter(Boolean);
    if (parts.length >= expectLen) {
      const nums = parts.slice(0, expectLen).map(parseFloat);
      if (nums.every(n => Number.isFinite(n))) out.push(nums);
    }
  });
  return out;
}

function buildMissionObj(ns, speed, takeoffH, yawMode, yawAngle, path, isGps, endZ=0.8) {
  const plan = [];
  plan.push({ behavior: "takeoff", method: "__call__", args: { height: takeoffH, speed: speed, wait: true } });
  if (isGps) {
    plan.push({ behavior: "follow_path_gps", method: "__call__", args: { geopath: path, speed: speed, yaw_mode: yawMode, yaw_angle: yawAngle, wait: true } });
  } else {
    plan.push({ behavior: "follow_path", method: "__call__", args: { path: path, speed: speed, yaw_mode: yawMode, yaw_angle: yawAngle, wait: true } });
  }
  plan.push({ behavior: "go_to", method: "__call__", args: { x: 0.0, y: 0.0, z: endZ, speed: 1.0, yaw_mode: 0, yaw_angle: 0.0, wait: true } });
  return { target: ns, plan };
}

function makeLoadPayload(ns, mid, missionObj) {
  return { drone_id: ns, mission_id: mid, action: 1, mission: JSON.stringify(missionObj) };
}
function makeStartPayload(ns, mid) { return { drone_id: ns, mission_id: mid, action: 2 }; }

function buildJSON() {
  const base = $("restBase").value.trim();
  const keyPrefix  = $("zenohKey").value.trim() || "rt";
  const ns   = $("ns").value.trim() || "uav_1";
  const mid  = parseInt($("mid").value, 10) || 5001;
  const spd  = parseFloat($("speed").value) || 3.0;
  const th   = parseFloat($("takeoffH").value) || 10.0;
  const endZ = parseFloat($("endZ").value) || 0.8;
  const yawMode  = parseInt($("yawMode").value, 10) || 0;
  const yawAngle = parseFloat($("yawAngle").value) || 0.0;

  const mode = $("mode").value;
  const gpsPts = parseList($("gpsPath").value, 3);
  const mapPts = parseList($("mapPath").value, 3);

  let mission, missionFallback;
  if (mode === "gps") {
    if (!gpsPts.length) return toast("请填写 GPS 点列");
    mission = buildMissionObj(ns, spd, th, yawMode, yawAngle, gpsPts, true, endZ);
  } else if (mode === "map") {
    if (!mapPts.length) return toast("请填写 map/odom 点列");
    mission = buildMissionObj(ns, spd, th, yawMode, yawAngle, mapPts, false, endZ);
  } else {
    if (!gpsPts.length && !mapPts.length) return toast("请至少填写一组点列（GPS 或 map）");
    if (gpsPts.length) mission = buildMissionObj(ns, spd, th, yawMode, yawAngle, gpsPts, true, endZ);
    if (mapPts.length) missionFallback = buildMissionObj(ns, spd, th, yawMode, yawAngle, mapPts, false, endZ);
  }

  const payloadLOAD  = makeLoadPayload(ns, mid, mission);
  const payloadSTART = makeStartPayload(ns, mid);

  $("payloadLOAD").value  = JSON.stringify(payloadLOAD, null, 2);
  $("payloadSTART").value = JSON.stringify(payloadSTART, null, 2);

  const primaryUrl = buildUrl(base, keyPrefix, ns, mid);
  let preview = primaryUrl;

  if (missionFallback) {
    const fallbackMid = mid + 1;
    const fallbackLOAD = makeLoadPayload(ns, fallbackMid, missionFallback);
    preview += `\n[回退任务 URL]\n${buildUrl(base, keyPrefix, ns, fallbackMid)}\n\n[回退任务 LOAD 示例]\n${JSON.stringify(fallbackLOAD, null, 2)}\n随后发送 START: ${JSON.stringify(makeStartPayload(ns, fallbackMid))}`;
    setResp("[回退任务] 已在目标 URL 预览中展示。");
  } else {
    setResp("已生成主任务载荷，可直接下发。");
  }
  $("targetUrl").textContent = preview;
}

function buildUrl(base, keyPrefix, ns, missionId) {
  const cleanBase = base.replace(/\/$/, '');
  const key = `${keyPrefix}/${ns}/mission_update`;
  return `${cleanBase}/${key}`;
}

function urlFromPayload(payload) {
  const base = $("restBase").value.trim().replace(/\/$/, '');
  const keyPrefix  = $("zenohKey").value.trim() || "rt";
  const ns = (payload && payload.drone_id) ? String(payload.drone_id) : ($("ns").value.trim() || "uav_1");
  return buildUrl(base, keyPrefix, ns);
}

async function postJson(payload) {
  const url = urlFromPayload(payload);
  try {
    const res = await fetch(url, {
      method: "PUT",
      headers: { "Content-Type": "application/json; charset=utf-8" },
      body: JSON.stringify(payload)
    });
    const text = await res.text();
    setResp(`PUT ${url}\nSTATUS ${res.status}\n\n${text}`);
  } catch (e) {
    setResp(`请求失败：${e}`);
  }
}

function sendLOAD() {
  const data = $("payloadLOAD").value.trim();
  if (!data) return toast("请先生成 JSON");
  postJson(JSON.parse(data));
}
function sendSTART() {
  const data = $("payloadSTART").value.trim();
  if (!data) return toast("请先生成 JSON");
  postJson(JSON.parse(data));
}
async function sendBoth() {
  const load = $("payloadLOAD").value.trim();
  const start = $("payloadSTART").value.trim();
  if (!load || !start) return toast("请先生成 JSON");
  await postJson(JSON.parse(load));
  await new Promise(r => setTimeout(r, 600));
  await postJson(JSON.parse(start));
}
function prefillDemo() {
  $("gpsPath").value =
`40.440500, -3.689820, 10
40.440500, -3.689620, 10
40.440700, -3.689620, 10
40.440700, -3.689820, 10
40.440500, -3.689820, 10`;
  $("mapPath").value =
`0, 0, 10
50, 0, 10
50, 50, 10
0, 50, 10
0, 0, 10`;
}
function setResp(t){ $("resp").textContent = t; }
function toast(t){ alert(t); }

/* ===================== 新增：实时监听 ===================== */

let pollTimer = null;

function buildGetUrl(base, key) {
  const cleanBase = (base || "").replace(/\/$/, "");
  const cleanKey  = (key || "").replace(/^\//, "");
  return `${cleanBase}/${cleanKey}`;
}

function fmtStamp(stamp) {
  if (!stamp || (stamp.sec===undefined)) return "-";
  const ms = (Number(stamp.sec)||0)*1000 + Math.floor((Number(stamp.nanosec)||0)/1e6);
  const d = new Date(ms);
  return d.toISOString().replace("T"," ").replace("Z","");
}

function safeJson(text) {
  try { return JSON.parse(text); } catch { return null; }
}

async function fetchOnce(url) {
  const res = await fetch(url, { method: "GET", cache: "no-store" });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.text();
}

async function pullTelemetryOnce() {
  const base = $("restBase").value.trim();
  const key  = $("teleKey").value.trim();
  if (!base || !key) return;
  const url  = buildGetUrl(base, key);
  try {
    const raw = await fetchOnce(url);
    const text = extractZenohPayload(raw);   // ✅ 新增：解出有效载荷
    console.log(text)
    const obj  = safeJson(text);
    if (!obj) return;

    const pos = obj.position || {};
    $("t_frame").textContent = obj.frame_id || "-";
    $("t_stamp").textContent = obj.stamp ? fmtStamp(obj.stamp) : "-";
    $("t_x").textContent     = typeof pos.x === "number" ? pos.x.toFixed(3) : "-";
    $("t_y").textContent     = typeof pos.y === "number" ? pos.y.toFixed(3) : "-";
    const z = (typeof pos.z === "number") ? pos.z :
              (typeof obj.altitude === "number" ? obj.altitude : undefined);
    $("t_z").textContent     = (typeof z === "number") ? `${z.toFixed(3)} m` : "-";
  } catch (e) {
    // 静默或按需打印
    // console.warn(e);
  }
}


async function pullStatusOnce() {
  const base = $("restBase").value.trim();
  const key  = $("statusKey").value.trim();
  if (!base || !key) return;
  const url  = buildGetUrl(base, key);
  try {
    const raw  = await fetchOnce(url);
    const text = extractZenohPayload(raw);   // ✅ 新增：解出有效载荷
    console.log(text)
    const obj  = safeJson(text);
    $("statusView").textContent = obj ? JSON.stringify(obj, null, 2) : text;
  } catch (e) {
    // console.warn(e);
  }
}


function startListen() {
  stopListen();
  const ms = Math.max(100, parseInt($("pollMs").value,10) || 500);
  pollTimer = setInterval(() => {
    pullTelemetryOnce();
    pullStatusOnce();
  }, ms);
  toast("开始监听（轮询）");
}

function stopListen() {
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}

function decodeBase64ToUtf8(b64) {
  // 把 atob 的二进制字符串转为 Uint8Array，再用 TextDecoder 解码 UTF-8
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return new TextDecoder().decode(bytes);
}

// 输入：fetch 的 text()
// 输出：字符串形式的 payload（若能解出 JSON，就返回 JSON 字符串；否则原文）
function extractZenohPayload(rawText) {
  // 直接就是 JSON 对象/字符串的情况
  try {
    const maybe = JSON.parse(rawText);
    // Zenoh REST 常见格式：[{ key, value, encoding, timestamp }]
    if (Array.isArray(maybe)) {
      if (maybe.length === 0) return ""; // 没有样本
      const entry = maybe[0] || {};
      const enc = entry.encoding || "";
      let val = entry.value;

      if (enc.startsWith("zenoh/bytes")) {
        try {
          // value 是 base64（bytes），解成 UTF-8 文本
          val = decodeBase64ToUtf8(String(val || ""));
        } catch (e) {
          console.warn("base64 decode failed:", e);
          val = "";
        }
      } else if (typeof val !== "string") {
        // 有些网关可能给的是对象，兜底成字符串
        val = JSON.stringify(val ?? "");
      }
      return val;
    }
    // 不是数组，那就是直接是对象/原文本
    return rawText;
  } catch {
    // 不是 JSON，直接返回原文本
    return rawText;
  }
}

</script>
</body>
</html>
